iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0

昨天完成了 MCP Bridge。

Host 已經知道有哪些 Tool 可以交給模型,也加上了工具與檔案範圍的限制。

但 Tool 到底什麼時候該用,還是我們自己寫死的:

await tools.call(
    "read_file",
    {"path": "pyproject.toml"},
)

今天往前走一步。

不再替模型決定要用 read_file,也不先幫它填好參數,而是把 Tool 的名稱、說明和 Schema 交給模型,讓它自己選一次。

今天只跑一輪。

先確認「模型會不會做出正確的工具決策」,明天再把它接成完整迴圈。

一、ReAct 不只是把回答寫得更長

ReAct 的核心是讓 推理與行動交錯進行。

模型先根據目前資訊決定下一步,需要外部資料時就執行 Action,取得 Observation,再根據新資訊決定後續動作。

放到現在的 devbench,可以理解成:

問題
↓
模型判斷下一步
↓
Action:呼叫 Tool
↓
Observation:Tool 真正回傳的結果
↓
下一輪決策

例如:

flowchart LR
    Q[使用者問題] --> M[模型決定下一步]
    M --> A[Action:read_file]
    A --> O[Observation:檔案內容]
    O --> N[下一輪決策]

這裡最重要的是:

Action 和 Observation 都是可以驗證的。

我們可以知道模型:

選了哪個 Tool
帶了哪些參數
Tool 有沒有真的執行
最後回傳成功還是失敗

至於模型內部怎麼推理,不是我們拿來驗證程式是否正確的依據。

真正能查核的是它做了什麼,以及工具實際回了什麼。

ReAct 原始論文討論的也是推理與外部行動互相補充的流程,而不是單純要求模型把「思考過程」印得更長。

ReAct 論文

二、把 MCP Tool 直接交給模型

昨天的 MCP Bridge 已經把 Server 提供的 Tool 轉成 ToolSpec。

所以今天不用再另外維護一份:

TOOLS = [
    {
        "name": "read_file",
        ...
    }
]

而是直接使用:

tools.specs

接著把它交給 OllamaClient:

response = await llm.chat(
    [
        ChatMessage(
            role="system",
            content=SYSTEM,
        ),
        ChatMessage(
            role="user",
            content=DEFAULT_TASK,
        ),
    ],
    tools=tools.specs,
)

模型看到的資訊主要就是:

Tool 名稱
Tool 說明
參數名稱
JSON Schema

所以 Tool 的命名和描述其實很重要。

例如:

read_file

就比:

do_action

清楚。

同樣地:

path

也比:

input

更容易讓模型知道參數該放什麼。

模型並不知道我們腦中那份「這個 Tool 應該怎麼用」的隱藏規格。

它能判斷的,就是我們實際交給它的 Schema 和描述。

三、第一次讓模型自己選 Tool

今天的程式很短:

import asyncio

from ironman.agent import DEFAULT_TASK, SYSTEM
from ironman.llm import OllamaClient
from ironman.mcp_bridge import devbench
from ironman.models import ChatMessage


async def main() -> None:
    async with devbench() as tools, OllamaClient() as llm:
        response = await llm.chat(
            [
                ChatMessage(
                    role="system",
                    content=SYSTEM,
                ),
                ChatMessage(
                    role="user",
                    content=DEFAULT_TASK,
                ),
            ],
            tools=tools.specs,
        )

        if not response.message.tool_calls:
            raise RuntimeError(
                "本次模型沒有選擇工具,不能算通過工具決策實驗"
            )

        for call in response.message.tool_calls[:2]:
            observation = await tools.call(
                call.name,
                call.arguments,
            )

            assert not observation.is_error


if __name__ == "__main__":
    asyncio.run(main())

跟昨天最大的差別只有一個:

昨天是我們決定:

tools.call("read_file", ...)

今天變成:

模型決定 Tool
↓
Host 取得 tool_calls
↓
Host 檢查並執行

模型並沒有直接取得 MCP Server 的控制權。

真正執行工具的還是 Host。

四、為什麼今天只跑一輪?

假設模型回傳:

read_file(
    path="src/ironman/config.py"
)

Host 執行後取得:

Observation:
config.py 的實際內容

今天就停在這裡。

還沒有把 Observation 再送回模型。

所以這不是一個完整的 Agent Loop。

今天只驗證:

問題
↓
模型選 Tool
↓
Host 執行
↓
取得 Observation

而不是:

問題
↓
Tool
↓
Observation
↓
再思考
↓
再用 Tool
↓
...
↓
最後答案

刻意拆開的原因很簡單。

如果第一輪 Tool selection 都還不穩,就直接進完整迴圈,出錯時會很難判斷問題到底在哪裡。

今天先確認模型「會選」。

明天再讓它「會繼續走」。

五、判斷成功不能只看模型說了什麼

假設模型產生:

我要讀取 config.py

這還不算成功。

真正要看的,是:

Action
read_file(path="src/ironman/config.py")

↓

Observation
is_error = False
實際取得檔案內容

模型要求呼叫 Tool,只代表它提出一個 Action。

Tool 有沒有真的成功,要看 Observation。

所以今天的程式才會寫:

assert not observation.is_error

而不是只確認:

response.message.tool_calls

這個差別之後會一直出現:

模型提出行動,不等於行動已成功。

六、Live Test 和一般測試要分開

這次只在設定:

IRONMAN_LIVE=1

時真的連到本機 Ollama。

沒有設定時,涉及模型的測試會標記為:

skipped

這件事也要分清楚:

passed  → 實際執行,而且符合預期

failed  → 實際執行,但結果不符預期

skipped → 根本沒有執行這項測試

所以 skipped 不能拿來證明:

模型真的會選對 Tool。

要驗證模型行為,還是得跑一次 Live Test。

🧪 隔離環境實測

Day 12 程式實測結果

看結果時,我會先看兩件事:

Action 對不對?
Observation 有沒有成功?

這一輪成立,才有資格進下一步。

七、Observation 也會影響下一輪

明天要把 Observation 再交回模型。

這時候會遇到另一個問題:

Tool 到底應該回多少資訊?

例如只回:

執行失敗

資訊太少。

模型不知道到底是:

檔案不存在
參數格式錯誤
權限不足
還是 Server 出錯

下一輪就可能又做一次幾乎一樣的呼叫。

但另一個極端也不好。

如果直接把完整 stack trace、內部路徑甚至敏感資訊全部塞回去,不只浪費 Context,也可能造成資訊外洩。

所以比較理想的是:

正常結果
→ 回傳完成下一步需要的資料

預期中的錯誤
→ 給模型足夠修正的提示

權限或安全拒絕
→ 明確拒絕,但不洩漏內部資訊

Observation 不只是 Tool 的輸出。

它會直接變成模型下一輪判斷的依據。


昨天,我們完成了:

模型
↓
Host
↓
受限的 MCP Bridge
↓
devbench

今天補上第一個真正的模型決策:

問題
↓
模型選 Tool
↓
Action
↓
Observation

但目前還只走了一步。

真正的 ReAct 還需要把 Observation 放回對話,讓模型根據結果繼續決定:

下一步還要不要使用工具?
什麼時候已經有足夠資訊?
什麼時候該停止?

而一旦模型可以自己一直走,就一定要先回答另一個問題:

誰負責叫它停?

明天進入 Day 13:

用 Python 實作 ReAct:完成第一個會使用 MCP 的 Agent。


上一篇
Day 11:從 MCP 到 Agent:模型、工具與 Agent 框架各自負責什麼?
下一篇
Day 13:用 Python 實作 ReAct:完成第一個會使用 MCP 的 Agent
系列文
協定、框架、架構:一條龍搞懂 AI Agent 是怎麼被造出來的 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言